feat(web): first-class web surface — <plugin> web from the installed artifact, one browser relay, /web in dev (#564) - #620
Conversation
Lift the standalone MCP App host out of serve-app/serve-mcp-app.ts into
plain-Node modules under src/web-host/ that both `agent-bundle serve-app`
and the generated `<plugin> web` command (later lanes) run:
- select-app.ts: parseAppSelector (was parseServeAppSelector), appNameOf,
openApp over an AppSelectionSource, requireJsonObject.
- session.ts: openStdioAppSession / sessionAuthorityFor; the session now
carries a `selection` adapter over the SDK client.
- page.ts: renderWebHostPage({ script, seed }), WEB_HOST_TOKEN_HEADER
(x-agent-bundle-web-host), webHostContentSecurityPolicy; the HTML shell,
style, and escaping move here from serve-app-page.ts, the inline relay
script is replaced by the caller-supplied built page script, and the
seed element is #agent-bundle-web-host-seed.
- page-script.ts: readWebHostPageScript() reads dist/web-host/page.js once.
- host-server.ts: startWebHost / validPort / validProfile — the loopback
HTTP host as plain async acquire/release, closing newest-first.
serve-mcp-app.ts keeps its public surface and becomes the framework-side
orchestration (validate, artifact, launch env, session, selection, host)
inside the existing Effect scope; serve-app-page.ts is deleted and api.ts
switches to parseAppSelector.
page.ts imports WEB_HOST_SEED_ELEMENT_ID / WebHostPageSeed from
./browser/seed.ts, which Lane 2 owns and provides; no stub is committed.
The framework page script (dist/web-host/page.js) is also Lane 2's, so
tests/serve-app.test.ts needs Lane 2's build step to run green.
Tests: web-host-page.test.ts, web-host-select-app.test.ts (unit);
serve-app.test.ts updated for the new seed id and token header.
Generated <plugin> web replaces the spawnServeApp entry, so capability tables, doctor, and the mcp-app example now expose Apps from the composite artifact instead of spawning the framework CLI.
… and runWebCommand (#564) `web-host/launch.ts#resolveWebLaunch` turns a manifest `web.apps[]` entry into the StdioLaunch of its MCP server: this Node + the artifact-relative `entry` (refused when it escapes the plugin root or names no file: WebLaunchError `entry-outside-root` / `entry-missing`), cwd = plugin root, env = inherited string values, then the declared static env with `agent-bundle:path:*` tokens expanded (`plugin-data` → `<root>/.agent-bundle/web/<server>`, created when referenced), then the `AGENT_BUNDLE_PLUGIN_ROOT` anchor unless declared. `web-host/command.ts#runWebCommand` is the command: `[<server>/<app>] [--port N] [--open|--no-open] [--tool T] [--input JSON] [--allow <cap>]... [--profile <id>] [--json] [--help]`; coded WebCommandError (`usage`, `app-ambiguous`, `app-not-exposed` → 2; `manifest-missing`, `web-missing`, `manifest-invalid`, `server-exited` → 1); reads the manifest's web section, picks the App, launches → openStdioAppSession → openApp → startWebHost, prints the shared ready line or one stable JSON line, and stays until the signal aborts (0) or the server exits (1 with its stderr tail). The modules it drives are an injectable runtime so the command is unit-tested against fakes. `src/web-host.ts` is the public `agent-bundle/web-host` entry (rslib entry and package export land with Lane 2). Plain Node: no Effect, no compiler modules (AB6005/AB4837).
…e authored tree (#564) `RunGeneratedCliOptions.web?: GeneratedCliWebCommand` — when the generated bin passes it, `<plugin> web ...` reaches `web.run(argv after web, { name, signal, writeErr, writeOut })` right after the `--version` check and before the compiled command tree is consulted, and the root `--help` lists `web` among the top-level commands in sorted position. Without the hook nothing changes: `web` is an unknown command as before.
…#564) Delete tests/packed-serve-app-command.test.ts, tests/serve-app-command-spawn.test.ts, tests/serve-app-command.test.ts and fixtures/serve-app-command/ — the spawnServeApp route is replaced by the framework-owned web command. The AB4837 recovery expectation in cli-routes-build.test.ts and route-framework-imports.test.ts now reads the web.apps / <plugin> web sentence.
- tests/packed-web-command.test.ts (packed pool): fixtures/web-surface consumer built with the installed CLI, source removed (packed-deleted-source), then bin/<plugin>.mjs web --no-open --json as a real process: sorted JSON ready document, human ready line, seed element, CSP, 403 without the token header, SIGINT 130 / SIGTERM 143 with no surviving descendants, --help listing both the authored dashboard command and web, exit 1 without a manifest, exit 2 for an unexposed App or an --allow outside the vocabulary, no surviving agent-bundle or effect import. - packages/workbench/tests/web-command.e2e.test.ts (integration pool): builds examples/mcp-app, runs its bin, opens the page at 1440x900, waits out the binding status, asserts the sandbox-origin proxy frame and the srcdoc App frame, the rendered status panel, the relayed resources/read and tools/call round trips, the hidden consent panel, then SIGINT teardown. - tests/support/bin-process.ts: spawn/await-line/teardown helpers shared by both. - artifact-cli-bin.test.ts: a project with web.apps and no src/cli still emits bin/<plugin>.mjs whose --help lists web and whose manifest carries the web section. - rstest.integration-tests.ts: pool membership for the new files.
…command hosts the artifact it ships in (#564 integration)
…e collision); per-page opening id isolates concurrent dev /web pages; manifest keys via hasDataKeys
… every package build); web-config fixtures on #601 route ids; tool match via mcpRouteProtocolName; architecture prose marks the bridge fork as landed
🦋 Changeset detectedLatest commit: 4a37174 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
commit: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ab86e06b85
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| * root on any host (#564). Wave-2 bin emission consumes this row the same | ||
| * way `cliBinCapability` gates `bin/<plugin>.mjs`. | ||
| */ | ||
| export const webSurfaceCapability = 'web'; |
There was a problem hiding this comment.
Wire the web capability into emission
For custom targets whose web support differs from cli, this capability declaration has no effect: a repo-wide search finds it referenced only by its unit test, while planComposite, targetHostsCliBin, and routedCliBinTargetDiagnostics still gate every web-bearing bin exclusively on cliBinCapability. Consequently a target can advertise supported web and have the bin omitted, or advertise unavailable web and still receive it; consume this row in emission and validation instead of leaving the declared capability disconnected.
AGENTS.md reference: AGENTS.md:L11-L16
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 67047c4: every built-in adapter publishes webSurfaceCapability (consumed from capabilityTable.plugin.web), cli-bins.ts gates emission through one targetHostsGeneratedBin — cli for a routed bin, web for a command-less web-only bin — and validation emits an AB4341 warning (shape 9) naming a selected target whose web capability is missing, degraded, unavailable, or prohibited. Tests: adapter-capability-states.test.ts, web-config.test.ts, artifact-cli-bin.test.ts.
| await mkdir(pluginData, { recursive: true }); | ||
| } | ||
| return Object.freeze({ | ||
| args: Object.freeze([entry]), |
There was a problem hiding this comment.
Preserve configured MCP server arguments
When a compiled or prebuilt MCP server uses the supported mcp.servers.<id>.args setting, its normal host launch appends those arguments after the entry, but the web manifest stores no argument list and this launcher always spawns only [entry]. Any server that requires a flag or positional argument therefore starts differently under <plugin> web and may fail its handshake; carry server.args.slice(1) into the manifest and expand/pass them here.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 67047c4: the manifest web.apps[].args carries the server's declared arguments after the entry (server.args.slice(1)), web-host/manifest.ts validates them as strings, and web-host/launch.ts expands and passes them after the entry. Tests: web-launch.test.ts, web-manifest.test.ts, packed-web-command.test.ts.
| relative(options.artifactRoot, entry.output).replaceAll('\\', '/'), | ||
| ])); | ||
| const servers = new Map(options.model.mcpServers.map((server) => [server.id, server])); | ||
| const apps = options.model.web.apps.map((app) => { |
There was a problem hiding this comment.
Exclude target-omitted Apps from the web manifest
When an exposed App is scoped away from the current build—for example, its server targets both portable and claude, the App targets only claude, and the artifact selects portable—planCompiledMcpApps correctly omits its HTML, but this unconditional mapping still advertises it in the manifest. The generated command and dev /web route then launch the server and fail because its resource list cannot contain that App; validate the target mismatch or filter exposure using the same selected-target predicate as App compilation.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 67047c4: webManifestFor filters exposed Apps by targets ∩ selected, so an App omitted from every selected target does not appear in the manifest's web section. Test: artifact-cli-bin.test.ts (status/claude-only is absent from the portable artifact's web.apps); documented in the targets-artifacts reference (en/zh).
| } | ||
| const bins = model.packageBuild?.bins ?? []; | ||
| for (const bin of bins) { | ||
| if (bin.generatedCli?.commands.some((command) => command.path[0] === 'web') !== true) continue; |
There was a problem hiding this comment.
Reserve web aliases as well as command paths
When a top-level authored command has another path but declares aliases: ['web'], this check emits no AB4341, yet runGeneratedCliEntry dispatches any first argument equal to web before consulting the authored command tree. The alias is therefore silently unreachable in every web-enabled artifact; include top-level aliases in the reserved-name collision check.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 67047c4: validate.ts reserves web for top-level command aliases too (AB4341 shape 6, CLI alias "web" of <command> is reserved by the web surface). Test: web-config.test.ts (dashboard aliased to web); docs in docs/diagnostics.md and the CLI/configuration references.
…t-read rationale The comment said to switch to readWebManifest "once web-host/manifest.ts lands (Lane 3)" — that module landed in this same branch, and switching would be wrong anyway: doctor reads the web section leniently by design so it reports what a bundle contains instead of failing on a malformed manifest (the strict parser rejects the minimal manifests doctor accepts). Co-authored-by: Zack Jackson <ScriptedAlchemy@users.noreply.github.com>
Sol lane (source, 36 edits): drop comments that restate code, replace type-bypassing casts with narrowed types, reuse core/strict-json and core/errors helpers (parseJsonWithoutDuplicateKeys, isErrno, errorMessage), and make install/doctor read the manifest web section through web-host/manifest.ts instead of its own lenient parser. Grok lane (tests, 66 edits): drop restating comments, import WEB_HOST_SEED_ELEMENT_ID / WEB_HOST_TOKEN_HEADER / WebHostPageSeed instead of redeclaring them, narrow with instanceof instead of casts. Integrator: restore the three relay comments that pre-existed on main, fix manifest.ts narrowing with throw, give the doctor test a valid manifest web section.
…te/providers - web-host/session.ts: each close listener runs isolated (sync throw and rejection), matching McpSessionService's watcher dispatch; lease watchSessionClosed forwards the listener's promise so rejections are caught - entry-shell.ts: a command-less (web-only) bin mounts neither state nor providers, so their modules cannot keep <plugin> web from starting - docs/diagnostics.md: AB4341 documents nine shapes - tests: web-session.test.ts (throwing/rejecting listeners, lease), web-only bin with state+providers equals the plain web-only bin
generatedCliBinEntry gives every generated bin a generatedCli (empty for a web-only plugin), so targetHostsGeneratedBin always consulted cli and a custom adapter without a web row still emitted the web bin. generatedBinCapability decides by command count; test pits opposing cli/web states.
api.mdx: main's per-module table with the web-host row in place of the removed serve-app-command; api/_meta.json follows; package.json keeps the web-host tsconfig in typecheck.
…ath in cliBinSourceInputs; re-pin non-web template hash
…'s changeset untouched Co-authored-by: Zack Jackson <ScriptedAlchemy@users.noreply.github.com>
…clared-projection web launch, identity-keyed sessions, opening-tool policy, out-of-artifact web state (#628) * fix(564): move framework-owned web state outside the installed artifact <plugin> web anchored per-server plugin data at <pluginRoot>/.agent-bundle/web, mutating the installed artifact. Durable web state now lives under the user's home (~/.agent-bundle/web-data/<plugin>-<digest>/<server>), keyed by the resolved plugin root so two installs never share it, and a read-only install still launches when the server declares plugin-data state. The configured args still pass through app.args.map(expand) unchanged. Co-authored-by: Zack Jackson <ScriptedAlchemy@users.noreply.github.com> * fix(564): one effective web launch from declared projections; identity-keyed sessions, epoch retirement, opening-tool policy The dev /web route hardcoded target 'portable', so browser presentation forced the portable projection: a Claude- or Codex-only build could not open /web/<server>/<app> without mcp.json. The launch now resolves from the projections the artifact manifest declares, over the same canonical resolution mcp run and MCP sessions use (readTargetMcpServer + resolveMcpPathTokens, env values re-anchored through the target's stdio argument rule): an explicit ?target= is validated against the declared projections that launch the server (invalid is an error, never a fallback); projections sharing one normalized launch descriptor (command, args, cwd, declared env, runtime binding) open unprompted whatever the host order; materially different launches answer 409 naming the choices; no candidate reports the missing binding instead of synthesizing portable. Selection settles before any spawn. Web sessions are cached by epoch, server, and resolved launch identity, and retire on successful epoch publication only (artifact.available): new acquisitions use the new epoch, an old session leased only by this registry closes and releases its process and epoch reference, sessions pages still lease stay valid, and a failed rebuild retires nothing. Opening an App page is no longer an unbounded mutation: an opening tool annotated readOnlyHint: true runs on every load, any other opening tool runs once per session, tool, App, and input, and refreshes rebind the retained result. The page's previewProfile stays presentation-only. Co-authored-by: Zack Jackson <ScriptedAlchemy@users.noreply.github.com> * docs(564): document web launch selection, opening-tool policy, web state location, and session retirement Co-authored-by: Zack Jackson <ScriptedAlchemy@users.noreply.github.com> * chore(564): own changeset for the #620 follow-up; leave the merged PR's changeset untouched Co-authored-by: Zack Jackson <ScriptedAlchemy@users.noreply.github.com> * docs(website): dev /web launch selection, opening-tool policy, session retirement, web state location (en+zh) Co-authored-by: Zack Jackson <ScriptedAlchemy@users.noreply.github.com> * chore: changeset PR number (#628) Co-authored-by: Zack Jackson <ScriptedAlchemy@users.noreply.github.com> * fix(564): close a retired leased web session at its last lease release; share one in-flight opening call across concurrent loads Self-review findings on #628: a session retired by epoch publication while pages still leased it was deleted from the registry and never closed — closeSessionWhenUnleased on McpSessionService now closes it at the release of its last lease (immediately when nothing leases it). The opening-result cache retains the pending call rather than its settled value, so concurrent first loads of a mutating opening tool share one tools/call, and a failed call is dropped so the next load retries. AB8023 documents the /web launch selection statuses (404 invalid target or no candidate, 409 ambiguous). Co-authored-by: Zack Jackson <ScriptedAlchemy@users.noreply.github.com> --------- Co-authored-by: Zack Jackson <ScriptedAlchemy@users.noreply.github.com>
Closes #564.
Design note: #564 (comment) (item → file mapping; #592 constraints). Built on the merged composite root (#578,
62b69c068) and the typed App client / bridge (#601,0d4a37cef).What lands
One artifact root, one framework-owned command.
web: { apps, open? }inagent-bundle.config.tsexposes Apps already declared undermcp.servers.<id>.apps(config/normalize.ts,config/validate.ts,core/types.ts; invalid exposure isAB4341). The composite root'sagent-bundle.manifest.jsongains a strictwebsection (web-host/manifest.ts) andbin/<plugin>.mjscarries<plugin> web— compiled into the routed bin bybuild/entry-shell.ts/build/cli-bins.ts, with the host runtime and the page script inlined (virtual module,AB6005-clean; a routed bin withoutwebis byte-identical to before, hash-pinned intests/entry-shell.test.ts). Noweb/directory, nosrc/web/**routing universe.<plugin> webfrom the installed artifact.web-host/{command,launch,host-server,select-app,session,page,page-script}.ts+src/web-host.ts: reads the manifest besidebin/, spawns the plugin's own packed MCP server over the MCP client SDK (bundled), calls the opening tool once, serves the host page on loopback with the sandbox proxy on a second origin, appliesallowconsent policy, prints the ready line /--json.cli-entry.tsdispatcheswebbefore the authored tree. Requires the installed artifact only — nonode_modules/agent-bundle, no checkout.One browser relay, one bridge. The host-side relay is one module,
web-host/browser/frame-relay.ts(McpAppFrameRelay), mounted by the Workbench (packages/workbench/src/mcp/mcp-app-frame.tsximports it),serve-app, dev/web/<server>/<app>, and the artifact's<plugin> webpage (web-host/browser/main.ts→dist/web-host/page.js).serve-app-page.ts's inlineHOST_SCRIPTduplicate is deleted. Host bridge stayscreateMcpAppBridgeviaMcpAppRoutes; App side iscreateAppClient(#601). This PR authors no bridge.Dev parity.
dev/web-host-routes.tsmountsGET /web/<server>/<app>on the foreground server: same page, same relay, same routes; reads the active epoch's manifestwebsection; 404 for Apps not inweb.apps. Each page gets an opaqueopeningid so concurrent tabs on one session never bind each other's opening call (McpAppRoutescreate request acceptsopening).Response boundary. The page binds the opening call's
CallToolResultthe host already made (#562 path) — the same structured result the MCP projection lowers; no parallel rendering path.Removed.
agent-bundle/serve-app-command(spawnServeApp,serveAppArgv,locateFrameworkCli,ServeAppCommandError), its tests, fixture, docs, and theAB4837recovery prose that pointed at it. Capability tables / doctor / mcp-app example advertise<plugin> web.Bug fixed on the way.
dev/watcher.ts: the package build stagesdist/in a.dist.stage-*sibling the watcher did not ignore, so everydist/package build insideagent-bundle devinvalidated the epoch it had just produced (surfaced by the mcp-app example gaining a bin; reproduced with invalidation logging, root-caused, covered intests/dev-watcher.test.ts).cargo-hauler
dashboard.tsshim (#564 acceptance, cargo-hauler #107 §4)Dry-run on a local clone (
dryrun/564-web,b030a61, never pushed):src/cli/dashboard.ts(181 lines: checkout heuristics,node_modulescrawl foragent-bundle,serve-appspawn + stdout regex, SIGTERM relay,--targetpicker) deleted; replaced byweb: { apps: [{ app: 'hauler/dashboard', tool: 'hauler_status', allow: ['call-tool'] }] }(+3 lines) anddashboard→webon the usage line. Net +14 / −193.hauler webforwards throughsrc/scripts/hauler.ts's default case to the generated bin, which dispatchesweb. Phase B: built with this branch's tarballs, copiedartifact/elsewhere, deleted the build'snode_modules, rannode artifact/bin/cargo-hauler.mjs web --no-open --jsonandnode artifact/scripts/hauler.mjs web --no-open --jsonfrom an unrelated cwd: both served the host page (<title>hauler/dashboard</title>, fallback hidden), SIGINT → 130. NoAB4341/AB6005.Tests
pnpm build && pnpm typecheck && pnpm lint && pnpm test:unit(4076 passed),pnpm test:integration(1117 passed, incl.examples-real.e2e,mcp-app-real.e2e,serve-app.test,dev-web-host-routes,web-command.e2e),pnpm test:packed(34 passed, incl.packed-web-command: packed tarball → clean consumer → build → delete project source → spawnbin/<plugin>.mjs webas a real process),pnpm docs:site:build.web-config,web-manifest,web-host-select-app,web-host-page,web-host-frame-relay,web-launch,web-command,dev-web-host-routes,packed-web-command,web-command.e2e(Chromium),bin-processsupport.Docs / changeset
en + zh:
reference/configuration(web, bin ownership),reference/cli(<plugin> web),guide/authoring/mcp("Exposing an App in the browser"),guide/development/workbench(/web/...),reference/api,guide/distribution/validation,examples/mcp-app,guide/concepts/architecture(bridge fork marked landed);docs/diagnostics.md(AB4341shapes 1–8,AB4837recovery),docs/entry-conventions.md(<plugin> web,agent-bundle/web-host). Oneminorchangeset (.changeset/564-web-surface.md).Deslop
Deslop: GPT-5.6 Sol (source lane), 36 edits; Grok 4.6 (tests lane), 66 edits — 102 edits over the diff vs
origin/main, commit6756481d1. Restating comments and interface-field doc noise removed;ascasts replaced by narrowed types (requireJsonObject,isPlainRecord,instanceof);core/*helpers reused (parseJsonWithoutDuplicateKeys,isErrno,errorMessage); tests importWEB_HOST_SEED_ELEMENT_ID/WEB_HOST_TOKEN_HEADER/WebHostPageSeedinstead of redeclaring them; the listener try/catch inweb-host/session.tsteardown and the test-siderequestBodytry/catch dropped. Integrator fix-ups: threeframe-relay.tscomments the lane cut pre-existed onmain(restored);manifest.tsnarrowing viathrowinstead of casts; the lane's switch ofinstall/doctor.tsto the strictreadWebManifestwas dropped in favour of the owner's lenient read (74bb06a68).Self-review
Reviewer:
change-risk-reviewer(GPT-5.6 Sol) on the integrated diff vsorigin/main; author model Claude Fable 5.1.Pass 1
config/normalize.ts:webcould silently replace a conventionalsrc/cli.tsbin, or be silently dropped when an explicitbinclaims the plugin name /bin: false.415bdcef4):webnever displaces an authored executable; validation emitsAB4341naming the owner (shapes 7–8), tests inweb-config.test.ts.dev/web-host-routes.ts: concurrent/webpages sharing a server/tool overwrote each other's opening call.415bdcef4): per-page opaqueopeningid stamped in the seed, sent on create, keyed in the dev routes (bounded FIFO);McpAppRoutesrejects a malformed or unknownopening. Tests indev-web-host-routes.test.ts,mcp-app-routes.test.ts.web-host/manifest.tsexactKeysduplicated strict-object validation.hasDataKeysfromcore/strict-json.ts.Codex review (commit
ab86e06b8) — all four threads answered inline; fixed in67047c4fc.webcapability row was declared but nothing consulted it for emission.webSurfaceCapability;cli-bins.tsgates emission oncli(routed bin) orweb(web-only bin);AB4341shape 9 warns on a selected target whosewebrow is missing/degraded/unavailable/prohibited.argswere dropped byweb-host/launch.ts.web.apps[].args(validated as strings), expanded and passed after the entry.websection.webManifestForfilters bytargets ∩ selected;artifact-cli-bin.test.tsassertsclaude-onlyis absent from theportableartifact.webwas not reserved.AB4341shape 6 covers aliases; test inweb-config.test.ts.Pass 2 (after the deslop commit
6756481d1and the fixes above)web-host/session.ts: teardown listeners ran without isolation after deslop dropped the try/catch; a sync throw aborted the remaining listeners and escaped the transport's close callback, a rejection went unhandled.67047c4fc):notifyClosedisolates each listener (Promise.resolve(listener()).catch, sync throw caught), matchingMcpSessionService's watcher dispatch; the lease'swatchSessionClosedforwards the listener promise. Newtests/web-session.test.tsruns throwing, rejecting, unsubscribed, and late listeners against a real stdio server and asserts nounhandledRejection.build/entry-shell.ts: a web-only bin with configured state/providers still imported and initialised them.67047c4fc): a command-less bin mounts neither state nor providers;entry-shell.test.tsasserts a web-only bin with state + providers compiles to the same source as one without.docs/diagnostics.mdsaid AB4341 has "six message shapes".Pass 3 (after pass-2 fixes)
build/cli-bins.ts: every generated bin carries ageneratedCli(empty for a web-only plugin), sotargetHostsGeneratedBinalways judged oncli; a custom adapter without awebrow still emitted the web bin, contradicting AB4341 shape 9.ef3a25dda):generatedBinCapabilitydecides by command count;adapter-capability-states.test.tspits opposingcli/webstates.docs/diagnostics.mdstill documentsagent-bundle serve-app.serve-appCLI command still exists (src/cli.ts,src/serve-app/**); this PR removes only theagent-bundle/serve-app-commandexport (the routed-CLI spawner), so the diagnostics prose is accurate.